{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# 推荐系统之矩阵分解与FM\n",
    "\n",
    "**协同过滤算法的不足：**\n",
    "\n",
    "协同过滤算法的特点就是完全没有利用到物品本身或者是用户自身的属性， 仅仅利用了用户与物品的交互信息就可以实现推荐，是一个可解释性很强， 非常直观的模型， 但是也存在一些问题， 第一个就是处理稀疏矩阵的能力比较弱，而实际问题中，巨大的用户量和商品量决定了表征这些量的矩阵会是非常稀疏的。\n",
    "\n",
    "为了使得协同过滤更好处理稀疏矩阵问题， 增强泛化能力， 从协同过滤中衍生出矩阵分解模型(Matrix Factorization,MF)或者叫隐语义模型，意思是在协同过滤共现矩阵的基础上， 使用更稠密的隐向量表示用户和物品， 挖掘用户和物品的隐含兴趣和隐含特征， 在一定程度上弥补协同过滤模型处理稀疏矩阵能力不足的问题。\n",
    "\n",
    "\n",
    "\n",
    "\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 隐语义模型\n",
    "\n",
    "核心思想是通过隐含特征（latent factor）联系用户兴趣和物品（item）， 基于用户的行为找出潜在的主题和分类， 然后对item进行自动聚类，划分到不同类别/主题(用户的兴趣)。\n",
    "\n",
    "**其与协同过滤的不同之处，通俗解释，就是找出的用户感兴趣的特征，不是明面上的，而是分析其行为得到特征再聚类，再将这一类中的物品进行推荐**\n",
    "\n",
    "原理对比：\n",
    "\n",
    "![avatar](http://misaka19998.gitee.io/picture/teamlearning/recommender%20system/%E7%9F%A9%E9%98%B5%E5%88%86%E8%A7%A3%E4%B8%8E%E5%8D%8F%E5%90%8C%E8%BF%87%E6%BB%A4%E5%AF%B9%E6%AF%94.png)\n",
    "\n",
    "假设每个用户都有自己的听歌偏好， 比如A喜欢带有小清新的， 吉他伴奏的， 王菲的歌曲，如果一首歌正好是王菲唱的， 并且是吉他伴奏的小清新， 那么就可以将这首歌推荐给这个用户。 也就是说是小清新， 吉他伴奏， 王菲这些元素连接起了用户和歌曲。 当然每个用户对不同的元素偏好不同， 每首歌包含的元素也不一样， 所以我们就希望找到下面的两个矩阵：\n",
    "\n",
    "潜在因子—— 用户矩阵Q  \n",
    "这个矩阵表示不同用户对于不同元素的偏好程度， 1代表很喜欢， 0代表不喜欢\n",
    "\n",
    "| |小清新|重口味|优雅|伤感|五月天|\n",
    "|--|--|--|--|--|--|\n",
    "|张三|0.6|0.8|0.1|0.1|0.7|\n",
    "|李四|0.1|0|0.9|0.1|0.2\n",
    "|王五|0.5|0.7|0.9|0.9|0\n",
    "\n",
    "潜在因子——音乐矩阵  \n",
    "表示每种音乐含有各种元素的成分， 比如下表中， 音乐A是一个偏小清新的音乐， 含有小清新的Latent Factor的成分是0.9， 重口味的成分是0.1， 优雅成分0.2\n",
    "\n",
    "| |小清新|重口味|优雅|伤感|五月天|\n",
    "|--|--|--|--|--|--|\n",
    "音乐A|0.9|0.1|0.2|0.4|0\n",
    "音乐B|0.5|0.6|0.1|0.9|1\n",
    "音乐C|0|0.6|0.1|0.2|0\n",
    "\n",
    "得出张三对音乐A的喜欢程度：\n",
    "\n",
    "张三对小清新的偏好 * 音乐A含有小清新的成分 + 张三对重口味的偏好 * 音乐A含有重口味的成分 + 张三对优雅的偏好 * 音乐A含有优雅的成分\n",
    "\n",
    "这是不是有点像链式求导法则？\n",
    "\n",
    "对应的隐向量：\n",
    "\n",
    "| |小清新|重口味|优雅|伤感|五月天|\n",
    "|--|--|--|--|--|--|\n",
    "|张三|0.6|0.8|0.1|0.1|0.7|\n",
    "\n",
    "| |小清新|重口味|优雅|伤感|五月天|\n",
    "|--|--|--|--|--|--|\n",
    "音乐A|0.9|0.1|0.2|0.4|0\n",
    "\n",
    "张三对音乐A的打分：\n",
    "\n",
    "$$0.6 * 0.9 + 0.8 * 0.1 + 0.1 * 0.2 + 0.1 * 0.4 + 0.7 * 0 = 0.69$$\n",
    "\n",
    "计算得到每个人对每种音乐的分数\n",
    "\n",
    "| |音乐A|音乐B|音乐C|音乐D|\n",
    "|--|--|--|--|--|\n",
    "|张三|0.68|1.58|0.28|0.1|0.51|\n",
    "|李四|0.31|0.43|0.47|0.11|\n",
    "|王五|1.06|1.57|0.73|0.69|\n",
    "\n",
    "**上面例子中的小清晰， 重口味， 优雅这些就可以看做是隐含特征， 而通过这个隐含特征就可以把用户的兴趣和音乐的进行一个分类， 其实就是找到了每个用户每个音乐的一个隐向量表达形式**\n",
    "\n",
    "**把协同过滤算法进行了一种延伸， 把用户的相似性和物品的相似性通过了一个叫做隐向量的方式进行表达**"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "但是在真实情况下是很难找到上面的矩阵，而且实际场景中一般是只有评分矩阵，而且是存在很多缺失值的稀疏矩阵，此时就要通过矩阵分解来解决这个问题\n",
    "\n",
    "**思路：基于这个评分矩阵去找到上面例子中的那两个矩阵， 也就是用户兴趣和物品的隐向量表达， 然后就把这个评分矩阵分解成Q和P两个矩阵乘积的形式， 这时候就可以基于这两个矩阵去预测某个用户对某个物品的评分了。 然后基于这个评分去进行推荐。**\n",
    "\n",
    "我们就可以通过分解协同过滤的共现矩阵来得到用户和物品的隐向量\n",
    "\n",
    "矩阵分解算法将m×n维的共享矩阵R分解成m×k维的用户矩阵U和k×n维的物品矩阵V相乘的形式。其中 m是用户数量， n是物品数量，k是隐向量维度， 也就是隐含特征个数， 只不过这里的隐含特征变得不可解释了， 即我们不知道具体含义了， 要模型自己去学。\n",
    "\n",
    "k的大小决定了隐向量表达能力的强弱，k越大， 表达信息就越强， 理解起来就是把用户的兴趣和物品的分类划分的越具体\n",
    "\n",
    "![avatar](http://misaka19998.gitee.io/picture/teamlearning/recommender%20system/%E7%9F%A9%E9%98%B5%E5%88%86%E8%A7%A3.png)\n",
    "\n",
    "有了用户矩阵和物品矩阵的话， 我们就知道了如果想计算用户u对物品i的评分\n",
    "\n",
    "$$\\operatorname{Preference}(u, i)=r_{u i}=p_{u}^{T} q_{i}=\\sum_{f=1}^{F} p_{u, k} q_{k,i}$$\n",
    "\n",
    "这里的 $p_u$就是用户u的隐向量， 就类似与上面的张三向量， 注意这是列向量，$q_i$是物品i的隐向量， 就类似于上面的音乐A向量， 这个也是列向量，所以才用了$p^T_u q_i$得到了一个数，也就是用户的最终评分，计算过程其实和上面例子中一样。这里的p_{u,k}和$q_{i,k}$是模型的参数， 也正是我们想办法要计算的，$p_{u,k}$度量的是用户u的兴趣和第k个隐类的关系，而$q_{i,k}$度量了第k个隐类和物品i之间的关系。"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 矩阵分解算法\n",
    "\n",
    "常用方法是特征值分解EVD或者奇异值分解SVD，由于基本方法不适用于大型稀疏矩阵，所以使用了优化后的算法。\n",
    "\n",
    "Funk-SVD：把求解上面两个矩阵的参数问题转换成一个最优化问题， 可以通过训练集里面的观察值利用最小化来学习用户矩阵和物品矩阵\n",
    "\n",
    "思路是随机创造出上面两个矩阵，然后求出其与真实值差值，用梯度下降法优化这个插值，推导过程如图：\n",
    "\n",
    "![avatar](http://misaka19998.gitee.io/picture/teamlearning/recommender%20system/%E7%9F%A9%E9%98%B5%E5%88%86%E8%A7%A3%E6%8E%A8%E5%AF%BC.jpg)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "使用之前的数据\n",
    "\n",
    "|  |物品1|物品2|物品3|物品4|物品5|\n",
    "|--|--|--|--|--|--|\n",
    "Alice|5|3|4|4|?\n",
    "用户1|3|1|2|3|3\n",
    "用户2|4|3|4|3|5\n",
    "用户3|3|3|1|5|4\n",
    "用户4|1|5|5|2|1\n",
    "\n",
    "实现矩阵分解的步骤：\n",
    "\n",
    "首先， 它会先初始化用户矩阵P和物品矩阵Q， P的维度是[users_num, F], Q的维度是[item_nums, F]， 这个F是隐向量的维度。 也就是把通过隐向量的方式把用户的兴趣和F的特点关联了起来。 初始化这两个矩阵的方式很多， 但根据经验， 随机数需要和 \n",
    "$1/ \\sqrt{F}$\n",
    "成正比。 下面代码中会发现。\n",
    " \n",
    "有了两个矩阵之后， 我就可以根据用户已经打分的数据去更新参数， 这就是训练模型的过程， 方法很简单， 就是遍历用户， 对于每个用户， 遍历它打分的物品， 这样就拿到了该用户和物品的隐向量， 然后两者相乘加上偏置就是预测的评分， 这时候与真实评分有个差距， 根据上面的梯度下降就可以进行参数的更新\n",
    "\n",
    "**为什么随机数需要与1/sqrt(F)成正比** \n",
    "\n",
    "math.sqrt(self.F)  for x in range(0, F)\n",
    "\n",
    "猜测是为了方便使得模型收敛，1/sqrt(F)的导数是(-1/(2Fsqrt(F))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 1,
   "metadata": {},
   "outputs": [],
   "source": [
    "class SVD():\n",
    "    def __init__(self, rating_data, F=5, alpha=0.1, lmbda=0.1, max_iter=100):\n",
    "        self.F = F           # 这个表示隐向量的维度\n",
    "        self.P = dict()          #  用户矩阵P  大小是[users_num, F]\n",
    "        self.Q = dict()     # 物品矩阵Q  大小是[item_nums, F]\n",
    "        self.bu = dict()   # 用户偏差系数\n",
    "        self.bi = dict()    # 物品偏差系数\n",
    "        self.mu = 0.0        # 全局偏差系数\n",
    "        self.alpha = alpha   # 学习率\n",
    "        self.lmbda = lmbda    # 正则项系数\n",
    "        self.max_iter = max_iter    # 最大迭代次数\n",
    "        self.rating_data = rating_data # 评分矩阵\n",
    "        \n",
    "        # 初始化矩阵P和Q, 方法很多， 一般用随机数填充， 但随机数大小有讲究， 根据经验， 随机数需要和1/sqrt(F)成正比\n",
    "        cnt = 0    # 统计总的打分数， 初始化mu用\n",
    "        for user, items in self.rating_data.items():\n",
    "            self.P[user] = [random.random() / math.sqrt(self.F)  for x in range(0, F)]\n",
    "            self.bu[user] = 0\n",
    "            cnt += len(items) \n",
    "            for item, rating in items.items():\n",
    "                if item not in self.Q:\n",
    "                    self.Q[item] = [random.random() / math.sqrt(self.F) for x in range(0, F)]\n",
    "                    self.bi[item] = 0\n",
    "        self.mu /= cnt\n",
    "        \n",
    "    # 有了矩阵之后， 就可以进行训练, 这里使用随机梯度下降的方式训练参数P和Q\n",
    "    def train(self):\n",
    "        for step in range(self.max_iter):\n",
    "            for user, items in self.rating_data.items():\n",
    "                for item, rui in items.items():\n",
    "                    rhat_ui = self.predict(user, item)   # 得到预测评分\n",
    "                    # 计算误差\n",
    "                    e_ui = rui - rhat_ui\n",
    "                    \n",
    "                    self.bu[user] += self.alpha * (e_ui - self.lmbda * self.bu[user])\n",
    "                    self.bi[item] += self.alpha * (e_ui - self.lmbda * self.bi[item])\n",
    "                    # 随机梯度下降更新梯度\n",
    "                    for k in range(0, self.F):\n",
    "                        self.P[user][k] += self.alpha * (e_ui*self.Q[item][k] - self.lmbda * self.P[user][k])\n",
    "                        self.Q[item][k] += self.alpha * (e_ui*self.P[user][k] - self.lmbda * self.Q[item][k])\n",
    "                    \n",
    "            self.alpha *= 0.1    # 每次迭代步长要逐步缩小\n",
    "    \n",
    "    # 预测user对item的评分， 这里没有使用向量的形式\n",
    "    def predict(self, user, item):\n",
    "        return sum(self.P[user][f] * self.Q[item][f] for f in range(0, self.F)) + self.bu[user] + self.bi[item] + self.mu   "
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 5,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "E 3.113004764479048\n"
     ]
    }
   ],
   "source": [
    "import random \n",
    "import math\n",
    "# 定义数据集， 也就是那个表格， 注意这里我们采用字典存放数据， 因为实际情况中数据是非常稀疏的， 很少有情况是现在这样\n",
    "def loadData():\n",
    "    rating_data={1: {'A': 5, 'B': 3, 'C': 4, 'D': 4},\n",
    "           2: {'A': 3, 'B': 1, 'C': 2, 'D': 3, 'E': 3},\n",
    "           3: {'A': 4, 'B': 3, 'C': 4, 'D': 3, 'E': 5},\n",
    "           4: {'A': 3, 'B': 3, 'C': 1, 'D': 5, 'E': 4},\n",
    "           5: {'A': 1, 'B': 5, 'C': 5, 'D': 2, 'E': 1}\n",
    "          }\n",
    "    return rating_data\n",
    " \n",
    "# 接下来就是训练和预测\n",
    "rating_data = loadData()\n",
    "basicsvd = SVD(rating_data, F=10)\n",
    "basicsvd.train()\n",
    "for item in ['E']:\n",
    "    print(item, basicsvd.predict(1, item))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 矩阵分解的优缺点分析\n",
    "\n",
    "**优点：**\n",
    "\n",
    "泛化能力强： 一定程度上解决了稀疏问题\n",
    "\n",
    "\n",
    "空间复杂度低： 由于用户和物品都用隐向量的形式存放， 少了用户和物品相似度矩阵， 空间复杂度由 $n^2$降到了 (n+m)∗f\n",
    "\n",
    "更好的扩展性和灵活性：矩阵分解的最终产物是用户和物品隐向量， 这个深度学习的embedding思想不谋而合， 因此矩阵分解的结果非常便于与其他特征进行组合和拼接， 并可以与深度学习无缝结合。\n",
    "\n",
    "**缺点：**\n",
    "\n",
    "矩阵分解算法依然是只用到了评分矩阵， 没有考虑到用户特征， 物品特征和上下文特征， 这使得矩阵分解丧失了利用很多有效信息的机会， 同时在缺乏用户历史行为的时候， 无法进行有效的推荐。"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## FM模型简介\n",
    "\n",
    "FM模型其实是一种思路，具体的应用稍少。一般来说做推荐CTR预估时最简单的思路就是将特征做线性组合（逻辑回归LR），传入sigmoid中得到一个概率值，本质上这就是一个线性模型，因为sigmoid是单调增函数不会改变里面的线性模型的CTR预测顺序，因此逻辑回归模型效果会比较差。\n",
    "\n",
    "总而言之，就是：\n",
    "\n",
    "1.是一个线性模型\n",
    "\n",
    "2.每个特征对最终输出结果独立，需要手动特征交叉$(x_i∗x_j)$，比较麻烦\n",
    "\n",
    "就考虑所有的二阶交叉项, 将目标函数$y = w_0+\\sum_{i=1}^nw_ix_i$变为$y = w_0+\\sum_{i=1}^nw_ix_i+\\sum_{i=1}^{n-1}\\sum_{i+1}^nw_{ij}x_ix_j $\n",
    "\n",
    "只有当$x_i$与$x_j$均不为0时这个二阶交叉项才会生效，后面这个特征交叉项本质是和多项式核SVM等价的\n",
    "\n",
    "**FM模型使用了如下的优化函数：**\n",
    "\n",
    "$$y = w_0+\\sum_{i=1}^nw_ix_i+\\sum_{i=1}^{n}\\sum_{i+1}^n\\lt v_i,v_j\\gt x_ix_j$$\n",
    "\n",
    "实质上就是给每个 \n",
    "x\n",
    "i\n",
    " 计算一个embedding，然后将两个向量之间的embedding做内积得到之前所谓的$w_{ij}$好处就是这个模型泛化能力强 ，即使两个特征之前从未在训练集中同时出现，我们也不至于像之前一样训练不出$w_{ij}$,事实上只需要$x_{i}$和其他的$x_{k}$同时出现过就可以计算出$x_{i}$的embedding！"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "从公式来看，模型前半部分就是普通的LR线性组合，后半部分的交叉项：特征组合。首先，单从模型表达能力上来看，FM是要强于LR的，至少它不会比LR弱，当交叉项参数$w_ij$全为0的时候，整个模型就退化为普通的LR模型。对于有n个特征的模型，特征组合的参数数量共有 $1+2+3+⋯+n−1=n(n−1)2$个，并且任意两个参数之间是独立的。所以说特征数量比较多的时候，特征组合之后，维度自然而然就高了。\n",
    " \n",
    "定理：任意一个实对称矩阵（正定矩阵）W都存在一个矩阵 V 使得W=V.VT成立。\n",
    "\n",
    "所有二次项参数$ω_{ij}$可以组成一个对称阵 W（为了方便说明FM的由来，对角元素可以设置为正实数），那么这个矩阵就可以分解为 W=VTV，V的第j列(vj)便是第j维特征(xj)的隐向量\n",
    "\n",
    "$$\\hat{y}(X) = \\omega_{0}+\\sum_{i=1}^{n}{\\omega_{i}x_{i}}+\\sum_{i=1}^{n-1}{\\sum_{j=i+1}^{n} \\color{red}{<v_{i},v_{j}>x_{i}x_{j}}}$$\n",
    "\n",
    "需要估计的参数有$ω_0∈R ωi∈R V∈R$ <⋅,⋅>是长度为 k的两个向量的点乘，公式如下：\n",
    "\n",
    "$$<v_{i},v_{j}> = \\sum_{f=1}^{k}{v_{i,f}\\cdot v_{j,f}}$$\n",
    "\n",
    "${ω_0}$为全局偏置；\n",
    "\n",
    "$ω_i$是模型第 i个变量的权重;\n",
    "\n",
    "$ω_{ij}=<vi,vj>$特征 i和 j的交叉权重;\n",
    "\n",
    "vi是第 i维特征的隐向量;\n",
    "\n",
    "<⋅,⋅>代表向量点积;\n",
    "\n",
    "k(k<<n)为隐向量的长度，包含k个描述特征的因子。\n",
    "\n",
    "FM模型中二次项的参数数量减少为kn个，远少于多项式模型的参数数量。另外，参数因子化使得\n",
    "$x_h,x_i$的参数和 \n",
    "$x_i,x_j$的参数不再是相互独立的，因此我们可以在样本稀疏的情况下相对合理地估计FM的二次项参数。具体来说， \n",
    "$x_h,x_i$\n",
    "和 ${x_i,x_j}$的系数分别为<vh,vi>和<vi,vj>，它们之间有共同项vi。也就是说，所有包含“ $x_i$的非零组合特征”（存在某个 j≠i，使得 \n",
    "$x_i,x_j≠0$）的样本都可以用来学习隐向量 vi，这很大程度上避免了数据稀疏性造成的影响。而在多项式模型中, $w_{hi}$和$w_{ij}$是相互独立的。\n",
    "\n",
    "显而易见，FM的公式是一个通用的拟合方程，可以采用不同的损失函数用于解决regression、classification等问题，比如可以采用MSE（Mean Square Error）loss function来求解回归问题，也可以采用Hinge/Cross-Entropy loss来求解分类问题。当然，在进行二元分类时，FM的输出需要使用sigmoid函数进行变换，该原理与LR是一样的。直观上看，FM的复杂度是$O(kn^2)$。但是FM的二次项可以化简，其复杂度可以优化到 O(kn)。由此可见，FM可以在线性时间对新样本作出预测。"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "$$ \\begin{align} \\sum_{i=1}^{n-1}{\\sum_{j=i+1}^{n}{<v_i,v_j>x_ix_j}}\n",
    "&= \\frac{1}{2}\\sum_{i=1}^{n}{\\sum_{j=1}^{n}{<v_i,v_j>x_ix_j}} - \\frac{1}{2} {\\sum_{i=1}^{n}{<v_i,v_i>x_ix_i}} \\\\\n",
    "&= \\frac{1}{2} \\left( \\sum_{i=1}^{n}{\\sum_{j=1}^{n}{\\sum_{f=1}^{k}{v_{i,f}v_{j,f}x_ix_j}}} - \\sum_{i=1}^{n}{\\sum_{f=1}^{k}{v_{i,f}v_{i,f}x_ix_i}} \\right) \\\\\n",
    "&= \\frac{1}{2}\\sum_{f=1}^{k}{\\left[ \\left( \\sum_{i=1}^{n}{v_{i,f}x_i} \\right) \\cdot \\left( \\sum_{j=1}^{n}{v_{j,f}x_j} \\right) - \\sum_{i=1}^{n}{v_{i,f}^2 x_i^2} \\right]} \\\\\n",
    "&= \\frac{1}{2}\\sum_{f=1}^{k}{\\left[ \\left( \\sum_{i=1}^{n}{v_{i,f}x_i} \\right)^2 - \\sum_{i=1}^{n}{v_{i,f}^2 x_i^2} \\right]} \\end{align} $$\n",
    "\n",
    "$v_i,f$是一个具体的值；\n",
    "\n",
    "第1个等号：对称矩阵 W对角线上半部分；\n",
    "\n",
    "第2个等号：把向量内积$v_i, v_j$展开成累加和的形式；\n",
    "\n",
    "第3个等号：提出公共部分；\n",
    "\n",
    "第4个等号：i和 j相当于是一样的，表示成平方过程。"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## FM模型的应用\n",
    "\n",
    "最直接的想法就是直接把FM得到的结果放进sigmoid中输出一个概率值，由此做CTR预估，事实上我们也可以做召回。\n",
    "\n",
    "由于FM模型是利用两个特征的Embedding做内积得到二阶特征交叉的权重，那么我们可以将训练好的FM特征取出离线存好，之后用来做KNN向量检索。\n",
    "\n",
    "#### 工业应用的具体操作步骤：\n",
    "\n",
    "离线训练好FM模型（学习目标可以是CTR）\n",
    "将训练好的FM模型Embedding取出\n",
    "将每个uid对应的Embedding做avg pooling（平均）形成该用户最终的Embedding，item也做同样的操作\n",
    "将所有的Embedding向量放入Faiss等\n",
    "线上uid发出请求，取出对应的user embedding，进行检索召回"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": []
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.7.6"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 4
}
